Day 7 看過,被攻擊版本的 claimMultiple 跟修補版只差 claimed = reward 和 claimed += reward 這一行。Day 11 最後留了一個問題:這一行怎麼讓攻擊者一直領到錢?
這篇先把 PoC 跑起來,看每一次呼叫實際領了多少,再把答案寫成測試。
這篇的測試是自己挑一組固定的輸入,例如 [13, 13],跑一次看結果對不對,英文叫 example test。後面幾天會換成讓 Foundry 自己產生輸入:
| 測試 | 輸入由誰決定 | 篇數 |
|---|---|---|
| Example test | 自己挑一組 | Day 12 |
| Fuzz test | Foundry 隨機產生很多組 | Day 13 |
| Invariant test | Foundry 隨機產生一連串操作 | Day 15 |
PoC 要把 BSC 在區塊 27,830,139 的狀態複製到本機 (fork),再在上面執行。這需要一個能查到舊區塊狀態的節點服務 (archive RPC),我之前用的服務跑到一半就回錯誤。
這次改用 evm-hack-registry 整理好的 anvil_state.json,裡面存了這個事件用到的合約和 storage。用 Foundry 內附的本機節點 anvil 載入後,本機就有一條停在那個區塊的鏈,測試直接 fork 本機:
anvil --port 8546 --load-state anvil_state.json --chain-id 56
我用的 anvil 1.2.3 要把檔案裡區塊資料的 beneficiary 欄位改名成 coinbase 才讀得進去,完整步驟放在實驗 repo 的 days/day12/README.md。
Foundry 執行時可以印出 trace,也就是每一次函式呼叫的參數和回傳值。以下的數字來自 PoC 完整跑一次的 trace。
PoC 第 4 步正常 claim(13) 的時候,claimable 回傳 1.5378 LVL,領走之後 claimed 是 1.5378。
接著 PoC 把時間往後調 5 小時,再呼叫 claimMultiple。這時 claimable(13) 回傳 7.6889 LVL。
變多了,是因為 epoch 13 的獎勵是分段解鎖 (vesting),時間越久能領的越多。算一下,這時解鎖的總額是 1.5378 + 7.6889 = 9.2267 LVL。
然後迴圈裡每一次的 Claimed 事件是這樣交替的:
| 第幾次 | 進迴圈時的 claimed |
claimable 回傳 |
執行後的 claimed |
|---|---|---|---|
| 1 | 1.5378 | 9.2267 − 1.5378 = 7.6889 | 7.6889 |
| 2 | 7.6889 | 9.2267 − 7.6889 = 1.5378 | 1.5378 |
| 3 | 1.5378 | 7.6889 | 7.6889 |
| … | … | … | … |
claimed = reward 直接把 claimed 蓋成「這一次領的量」,沒有累加。下一次 claimable 用解鎖總額減掉它,又算出剩下的那一份。兩次一組,每組領走一整份 9.2267。
陣列長度 2000,就是 1000 組,一次呼叫領走 9,226.7 LVL。
這要靠第 4 步先領過一次。如果進迴圈時 claimed 是 0,第一次就會被蓋成解鎖總額,之後每次都算出 0,只領到正常的量。
沒有檢查重複是真的,但如果 claimed 是累加,重複的 epoch 第二次會算出 0,陣列再長也沒用。
接下來把這件事寫成測試。測試裡的 _earnAndClaimOnce 照 PoC 的方式準備好狀態:在 epoch 13 交易賺到獎勵、結束 epoch、用 claim 正常領一次,再等 5 小時。準備好之後才呼叫 claimMultiple。測試只由一個帳戶來回換 20 次,交易量比 PoC 少,所以下面的數字比 trace 小。
每個測試都記錄呼叫前的 claimable,也就是這時應該能領的量,再用 assertEq 檢查實際拿到的 LVL 是不是等於它。相等就是 PASS,不相等就是 FAIL:
function testDuplicateEpochPaysOnlyClaimable() external {
_earnAndClaimOnce();
uint256 remaining = CONTROLLER.claimable(EPOCH, address(this));
uint256 paid = _claimMultiplePaid(2); // [13, 13]
assertEq(paid, remaining, "duplicate epoch paid more than claimable");
}
輸入是手選的 [13, 13],不是 PoC 的 2000 個。兩個就夠讓問題出現。
修補後的版本也要跑同一個測試。Foundry 有一些只在測試裡能用的特殊指令,叫 cheatcode,其中 vm.etch 可以直接換掉某個地址上的程式碼。我用它把修補版 0x0e6d6c… 的程式碼蓋到 0x9F00fbD6… 上,其他狀態不動。
結果:
| 測試 | 版本 | 輸入 | 結果 |
|---|---|---|---|
testSingleEpochPaysClaimable |
被攻擊的版本 | [13] |
PASS |
testDuplicateEpochPaysOnlyClaimable |
被攻擊的版本 | [13, 13] |
FAIL |
testFixedDuplicateEpochPaysOnlyClaimable |
修補後 | [13, 13] |
PASS |
FAIL 的那個,輸出的數字單位是 LVL 的最小單位,除以 10^18 才是 LVL:
[FAIL: duplicate epoch paid more than claimable: 4857801401954857773 != 4048167834962381478]
[13, 13] vulnerable
claimable before 4048167834962381478
claimed before 809633566992476295
paid 4857801401954857773
claimed after 809633566992476295
claimable 是 4.048 LVL,實際拿到 4.858 LVL,多出來的剛好是之前領過的 0.810。而且呼叫結束後 claimed 回到 0.810,跟呼叫前一樣:第一個 13 把它蓋成 4.048,第二個 13 又蓋回 0.810。所以下一次呼叫可以再來一遍。
修補後拿到 4.048,claimed 變成 4.858,等於解鎖的總額。
testSingleEpochPaysClaimable 只放一個 13,在被攻擊的版本上是 PASS 的:
[13]
claimable before 4048167834962381478
claimed before 809633566992476295
paid 4048167834962381478
claimed after 4048167834962381478
拿到的量等於 claimable,測試檢查的東西沒有錯。
但 claimed after 是 4.048,應該是 4.858。帳已經記錯了,只是這個測試沒有檢查 claimed。如果之後再呼叫一次 claimMultiple([13]),claimable 會變成 0.810,前面已經領過的那份可以再領一次。
所以這個 PASS 能說的只有:在這個狀態、這個輸入下,這一次拿到的量是對的。它不能說明這個函式沒有問題。
FAIL 的那個也一樣。它證明 [13, 13] 這條路徑會多付,不能說明還有沒有其他路徑。
明天把 epoch 和陣列內容交給 Foundry fuzz 去選,看它能不能自己找到會多付的輸入。